PYTHON-5903 Retry attempts should share a single stable operation_id#2901
PYTHON-5903 Retry attempts should share a single stable operation_id#2901shrey-mongo wants to merge 11 commits into
Conversation
247b26f to
682dbac
Compare
|
Please fill out the pull request template completely. |
There was a problem hiding this comment.
Pull request overview
This PR ensures command-monitoring operation_id remains stable across retry attempts for retryable reads/writes and relevant cursor operations, improving APM correlation for a single logical operation.
Changes:
- Mint a single per-operation
operation_idin the retryable-operation wrapper and stamp it onto the checked-out connection for each attempt. - Thread the connection’s
op_idthrough pool/server command execution intocommand_runnerso published command-monitoring events use it. - Add sync + async integration tests that force multiple retries via
failCommandand assert all related command events share oneoperation_id.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
tools/synchro.py |
Registers the new test file in the synchro conversion list. |
test/test_operation_id_retry.py |
Sync integration coverage asserting operation_id stability across retries (and no retry for non-retryable multi-writes). |
test/asynchronous/test_operation_id_retry.py |
Async equivalent of the retry operation_id stability tests. |
pymongo/synchronous/server.py |
Passes conn.op_id into cursor command execution for APM publishing. |
pymongo/synchronous/pool.py |
Adds Connection.op_id, forwards it into run_command, and clears it on check-in. |
pymongo/synchronous/mongo_client.py |
Mints/stabilizes per-operation ids across retries and stamps them on checked-out connections. |
pymongo/synchronous/command_runner.py |
Accepts/threads op_id through run_command / run_cursor_command into _run_command. |
pymongo/asynchronous/server.py |
Async counterpart: passes conn.op_id for cursor command execution. |
pymongo/asynchronous/pool.py |
Async counterpart: adds/forwards/clears op_id on connections. |
pymongo/asynchronous/mongo_client.py |
Async counterpart: stable per-operation id and per-attempt stamping onto connections. |
pymongo/asynchronous/command_runner.py |
Async counterpart: threads op_id through command execution to APM event publishing. |
|
Updated description to include full template |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
This needs to be updated to use the new operation_id, if present.
|
There are merge conflicts, please resolve. |
5546477 to
75f4e21
Compare
|
I fixed the merge conflict and the typing error that was causing the workflow to fail |
| func=func, | ||
| ) | ||
|
|
||
| def require_failCommand_appName(self, func): |
There was a problem hiding this comment.
What's the purpose of this change?
There was a problem hiding this comment.
It was needed to fix an untyped-decorator mypy error that was occurring in my test. This matches the signature of require_failCommand_fail_point.
There was a problem hiding this comment.
You don't need both the require_failCommand_fail_point and require_failCommand_appName annotations, the latter is a strict subset of the former.
1707aa0 to
63312b0
Compare
|
|
||
|
|
||
| class TestOperationIdRetry(AsyncIntegrationTest): | ||
| RETRIES = 5 # fail this many attempts; the (RETRIES + 1)th succeeds. |
There was a problem hiding this comment.
Why do we need 5 instead of 2 here? Does 5 provide additional test coverage or safety?
There was a problem hiding this comment.
You're right, no added benefit, dropped to 2
| """Force ``retries`` retries of ``action`` and assert every command | ||
| event for ``command_name`` shares one integer operation_id.""" | ||
| listener = AllowListEventListener(command_name) | ||
| client = await self.async_rs_or_single_client(event_listeners=[listener], appname=_APP_NAME) |
There was a problem hiding this comment.
Do we need to create a new client for every single operation, or can they all share a client set up in asyncSetUp?
There was a problem hiding this comment.
Fixed, the client and listener are created once in asyncSetUp and shared across all operations
| with self.subTest(command=command_name): | ||
| listener = AllowListEventListener(command_name) | ||
| client = await self.async_rs_or_single_client( | ||
| event_listeners=[listener], appname=_APP_NAME | ||
| ) | ||
| coll = client.pymongo_test.test_operation_id_retry | ||
| await self._seed(coll) | ||
| listener.reset() | ||
|
|
||
| fail_point = { | ||
| "mode": {"times": 1}, | ||
| "data": { | ||
| "failCommands": [command_name], | ||
| "closeConnection": True, | ||
| "appName": _APP_NAME, | ||
| }, | ||
| } | ||
| async with self.fail_point(fail_point): | ||
| with self.assertRaises(ConnectionFailure): | ||
| await action(coll) |
There was a problem hiding this comment.
This looks very similar to what _check_stable_operation_id does, can we refactor them to reduce code duplication?
There was a problem hiding this comment.
Refactored, both tests now call a shared _run_under_failpoint helper
| async with operation.conn_mgr._lock: | ||
| async with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type] | ||
| err_handler.contribute_socket(operation.conn_mgr.conn) | ||
| operation.conn_mgr.conn.op_id = _randint() |
There was a problem hiding this comment.
This will assign a new op_id to every call here even though nothing retryable goes through this path.
There was a problem hiding this comment.
Good point, I changed it to conn.op_id = None so each get More falls back to its own request_id.
| db, coll = namespace.split(".", 1) | ||
| spec = {"killCursors": coll, "cursors": cursor_ids} | ||
| # killCursors is its own op; drop any op_id left on a pinned cursor conn. | ||
| conn.op_id = None |
There was a problem hiding this comment.
A cleaner way of doing this might be to set conn.op_id = None in AsyncMongoClient._checkout to cover the edge cases and ensure that only the retryable logic sets a new op_id.
There was a problem hiding this comment.
Done. added conn.op_id = None on checkout and removed the clear from checkin so every checkout starts at None and only the retryable read/write logic sets a real op_id.
| more_to_come: bool = False, | ||
| unpack_res: Optional[Callable[..., Any]] = None, | ||
| cursor_id: Optional[int] = None, | ||
| op_id: Optional[int] = None, |
There was a problem hiding this comment.
Is this (and the one for run_command) ever used, since an op_id is set by the retryable read/write code in mongo_client.py?
There was a problem hiding this comment.
You're right, removed the op_id param and they read from conn.op_id directly now.
| user_fields=user_fields, | ||
| exhaust_allowed=exhaust_allowed, | ||
| write_concern=write_concern, | ||
| op_id=self.op_id, |
There was a problem hiding this comment.
We don't need to pass this since run_command is already passed the connection itself that owns op_id.
… into shrey-mongo/PYTHON-5903
| prev_op_id = conn.op_id | ||
| conn.op_id = None | ||
| await conn.authenticate(reauthenticate=True) | ||
| conn.op_id = prev_op_id |
There was a problem hiding this comment.
This should use a try-except-finally block to ensure the restore of the previous op_id always occurs.
| yield session._pinned_connection | ||
| return | ||
| async with await server.checkout(handler=err_handler) as conn: | ||
| conn.op_id = None # Only retryable read/write logic sets an op_id. |
There was a problem hiding this comment.
This check should be moved to before the if in_txn block to prevent a pinned connection from re-using an old op_id.
| # Each operation, paired with the wire command it issues and an awaitable action. | ||
| # These are all retryable; a stable operation_id must span every retry attempt. |
There was a problem hiding this comment.
This comment isn't needed: it describes a known standard pattern in the codebase.
NoahStapp
left a comment
There was a problem hiding this comment.
What do you think about using a ContextVar for the operationId instead of storing it on the connection? That would remove the current friction between a per-operation object being stored on a cross-operation object and simplify the cleanup significantly.
We already use this pattern in _csot.py and elsewhere for exactly the reason it might be useful here: to avoid threading through the entire call stack while still centralizing cleanup.
Something like this in _ClientConnectionRetryable.run:
token = _op_id.OP_ID.set(self._operation_id)
try:
return await self._run()
finally:
_op_id.OP_ID.reset(token)
And then replacing the current _handle_reauth block with this:
token = _op_id.OP_ID.set(None)
try:
await conn.authenticate(reauthenticate=True)
finally:
_op_id.OP_ID.reset(token)
The other cleanups could then be removed since they don't interact with _ClientConnectionRetryable.run() at all.
|
Implemented the I set the token in |
… into shrey-mongo/PYTHON-5903 # Conflicts: # pymongo/asynchronous/mongo_client.py # pymongo/synchronous/mongo_client.py
… into shrey-mongo/PYTHON-5903
NoahStapp
left a comment
There was a problem hiding this comment.
OP_ID needs to be cleared inside AsyncPeriodicExecutor._run(), just as _csot.reset_all() is to prevent bleeding of op ids between async tasks.
Also look at _csot.py for an example of how to consolidate the OP_ID set/unset logic into a potential context manager.
| codec_options=codec_options, | ||
| user_fields=user_fields, | ||
| orig=orig, | ||
| op_id=_op_id.OP_ID.get(), |
There was a problem hiding this comment.
We can consolidate these explicit passes of op_id to a new block within _run_command:
if op_id is None:
op_id = _op_id.OP_ID.get()
| if self._retrying: | ||
| self._log_retry(is_write=True) | ||
| return await self._func(self._session, conn, self._retryable) # type: ignore | ||
| # Publish this op's id on every command of every attempt. |
There was a problem hiding this comment.
| # Publish this op's id on every command of every attempt. | |
| # One operation id across all attempts of this operation. |
| if self._retrying: | ||
| self._log_retry(is_write=False) | ||
| return await self._func(self._session, self._server, conn, read_pref) # type: ignore | ||
| # Publish this op's id on every command of every attempt. |
There was a problem hiding this comment.
| # Publish this op's id on every command of every attempt. | |
| # One operation id across all attempts of this operation. |
|
|
||
|
|
||
| class TestOperationIdRetry(AsyncIntegrationTest): | ||
| RETRIES = 2 # fail this many attempts; the (RETRIES + 1)th succeeds. |
There was a problem hiding this comment.
Is this comment adding something non-obvious to a reader of the code?
| """Force ``times`` closeConnection failures of ``command_name``, run | ||
| ``action``, and return its ``(started, failed, succeeded)`` events.""" |
There was a problem hiding this comment.
| """Force ``times`` closeConnection failures of ``command_name``, run | |
| ``action``, and return its ``(started, failed, succeeded)`` events.""" | |
| """Set a failpoint for the given command and return the corresponding events published during its execution.""" |
| await self.coll.insert_many([{"_id": i, "x": i % 3} for i in range(5)]) | ||
| await self.coll.create_index("x") | ||
|
|
||
| async def _run_under_failpoint(self, command_name, action, times, expected_error=None): |
There was a problem hiding this comment.
| async def _run_under_failpoint(self, command_name, action, times, expected_error=None): | |
| async def _run_under_failpoint(self, name, f, times, expected_error=None): |
| ("listIndexes", lambda c: _list_indexes(c)), | ||
| ] | ||
|
|
||
| _COMMANDS = {name for name, _ in _RETRYABLE_WRITES + _RETRYABLE_READS} |
There was a problem hiding this comment.
If this isn't going to be reused, can it be defined inline at its use instead?
| for command_name, action in _RETRYABLE_WRITES: | ||
| with self.subTest(command=command_name): | ||
| await self._check_stable_operation_id(command_name, action, self.RETRIES) | ||
|
|
There was a problem hiding this comment.
| for command_name, action in _RETRYABLE_WRITES: | |
| with self.subTest(command=command_name): | |
| await self._check_stable_operation_id(command_name, action, self.RETRIES) | |
| for name, f in _RETRYABLE_WRITES: | |
| with self.subTest(command=name): | |
| await self._check_stable_operation_id(name, f, self.RETRIES) | |
There was a problem hiding this comment.
We use the name, f convention for similar tests elsewhere.
| for command_name, action in _RETRYABLE_READS: | ||
| with self.subTest(command=command_name): | ||
| await self._check_stable_operation_id(command_name, action, self.RETRIES) |
There was a problem hiding this comment.
| for command_name, action in _RETRYABLE_READS: | |
| with self.subTest(command=command_name): | |
| await self._check_stable_operation_id(command_name, action, self.RETRIES) | |
| for name, f in _RETRYABLE_READS: | |
| with self.subTest(command=name): | |
| await self._check_stable_operation_id(name, f, self.RETRIES) |
| async def test_non_retryable_write_is_not_retried(self): | ||
| # Multi-document writes are not retryable: a single network error must | ||
| # surface immediately, with exactly one attempt. | ||
| for command_name, action in [ |
There was a problem hiding this comment.
| for command_name, action in [ | |
| for name, f in [ |
… into shrey-mongo/PYTHON-5903
PYTHON-5903: Retry attempts should share a single stable operation_id
Changes in this PR
Command-monitoring events carry an
operation_idto correlate the events of one logical operation. For single-document CRUD and cursor operations, each retry attempt got a differentoperation_id(it defaulted to the per-attemptrequest_id), so consumers couldn't group a retried operation's events. Bulk writes already shared one across batches._ClientConnectionRetryablemints theoperation_idonce and stamps it on the checked-out connection (conn.op_id) on every attemptConnectiongains anop_idattribute, reset on check-in.pool.commandandserver.run_operationsourceop_idfrom the connection and pass it tocommand_runner.run_command/run_cursor_command, which thread it through to thepublish_command_*events.Test Plan
test/asynchronous/test_operation_id_retry.pyforces multiple retries via afailCommandfailpoint across a read/write matrix and asserts all command events share oneoperation_id; verifies non-retryable multi-writes don't retry.Checklist
Checklist for Author
Checklist for Reviewer